第八章 高性能服务器程序框架

Huan Lee Lv5

根据服务器程序的一般原理, 将服务器解构为三个主要模块:

  1. IO处理单元. 本章介绍四种IO模型和两种高效事件处理模式

  2. 逻辑单元. 本章介绍两种高效并发模式, 以及有限状态机

  3. 存储单元

请求队列是各单元之间通信方式的抽象.

8.1 服务器模型

C/S模型

Untitled

C/S模型适合资源相对集中的场合.

缺点: 服务器时通信的中心, 当访问量过大时, 可能所有客户端都将得到很慢的响应. (对服务端不友好)

Untitled

P2P模型

P2P模型使得每台机器在消耗服务的同时也给别人提供服务.

缺点: 当用户之间传输的请求过多时, 网络的负载将加重(对客户端不友好)

Untitled

8.3 IO模型

Untitled

socket在创建时默认时阻塞的, 可能被阻塞的系统调用包括accept, send, recv和connect

针对非阻塞的socket, 这些系统调用返回-1, 需要根据errno进行区分. 对accept, send, recv而言, 事件未发生时errno通常为EAGAIN(再来一次)或者EWOULDBLOCK(期望阻塞); 对connect而言, errno则被设置为EINPROGRESS(处理中)

显然, 只有在事件已经发生的情况下操作非阻塞IO才能提高程序的效率, 因此非阻塞IO通常要和其他IO通知机制一起使用.

  • IO复用时最常使用的IO通知机制, 应用程序通过IO复用函数向内核注册一组事件, 内核通过IO复用函数把其中就绪的事件通知给应用程序. IO复用函数本身是阻塞的, 它们能提高程序效率的原因在于它们具有同时监听多个IO事件的能力.
  • SIGIO信号也可以用来处理IO事件. 可以为一个目标文件描述符指定宿主进程, 那么被指定的宿主进程将捕获到SIGIO信号. 这样, 当目标文件描述符上有事件发生时, SIGIO信号的信号处理函数将被触发, 我们也就可以在该信号处理函数中对目标文件描述符执行非阻塞IO操作了.

阻塞IO, IO复用和信号驱动IO都是同步IO模型, 因为这三种IO模型中, IO的读写操作都是在IO事件之后, 由应用程序完成.

而POSIX规范所定义的异步IO模型则不同, 对异步IO而言, 用户可以直接对IO执行读写操作, 这些操作告诉内核用户读写缓冲区的位置, 以及IO操作完成后内核通知应用程序的方式. 异步IO的读写操作总是立即返回, 而不论IO是否阻塞, 因为真正的读写操作由内核接管.

同步IO和异步IO

在IO模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核)

同步IO的特点:

  1. 同步 io 是用户线程发起 io 请求并以阻塞或轮询的方式来等待 io 事件的完成(内核态数据准备就绪的就绪事件)

  2. 同步 io 是 io 的发起方,同时也是处理方

  3. 同步 io 是需要将内核态准备就绪的数据拷贝到用户态,所以需要阻塞用户态程序并等待 io 完成

异步IO的特点:

  1. 异步 io 在用户线程发起 io 请求后会立即返回继续执行后续的逻辑流

  2. 异步 io 是 io 的发起方,但内核态才是处理方

  3. 异步 io 的处理方是内核态,所以不需要阻塞 (程序告诉内核读写缓存区的位置即可, 通知完成事件)

阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。

非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。

异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。

8.4 两种高效的事件处理模式

同步IO模型通常用于实现Reactor模式, 异步IO模型则用于实现Proactor模式.

Untitled

Reactor模式

Reactor是这样一种模式,它要求主线程(I/O处理单元, 下同)只负责监听文件描述上是否有事件发生, 有的话就立即将该事件通知工作线程(逻辑单元, 下同). 除此之外, 主线程不做任何其他实质性的工作. 读写数据, 接受新的连接, 以及处理客户请求均在工作线程中完成.

Untitled

Untitled

工作线程从请求队列中取出事件后, 将根据事件的类型来决定如何处理, 因此图中的Reactor模式中, 没必要区分”读工作线程”和”写工作线程”.

Proactor模式

https://www.xiaolincoding.com/os/8_network_system/reactor.html#proactor

与Reactor模式不同, Proactor模式将所有IO操作都交给主线程和内核来处理(是异步IO模型), 工作线程仅仅负责业务逻辑. 因此Proactor模式更符合图8-4 (服务器基本框架)

Untitled

Untitled

  • 连接socket上的读写事件是通过aio_read/aio_write向内核注册的, 因此内核将通过信号向应用程序报告连接socket上的读写事件. 所以, 主线程中的epoll_wait调用能用来监听socket上的连接请求事件, 而不能用来检测连接上的读写事件.

    • epoll_wait能够通知事件是否准备就绪(通过请求队列); 而aio_read/aio_write则能直接将数据读入缓存区或从缓存区读出, 并直接通知事件是否已经完成(通过信号)

模拟Proactor模式

使用同步IO模拟Proactor模式: 主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理(这点与Proactor模式一致)

Untitled

8.5 两种高效的并发模式

当程序是IO密集型, 采用并发编程能够提高CPU的利用率.

半同步/半异步模式

半同步/半异步模式中的“同步”和“异步”与前面讨论的I/O模型中的“同步”和“异步”是完全不同的概念!

  • 在IO模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核)。

  • 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行; “异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。

Untitled

异步线程的执行效率高, 实时性强, 但是代码复杂, 难以调试和扩展; 同步线程虽然效率相对较低, 但是逻辑简单. 因此对服务器这种既要求较好的实时性, 又要求能同时处理多个用户请求的应用程序, 应该同时使用同步线程和异步线程来实现, 即采用半同步/半异步模式.

同步线程处理客户逻辑, 相当于逻辑单元; 异步线程处理IO事件, 相当于IO处理单元. 异步线程监听到客户请求后, 将其封装成请求对象并插入请求队列. 请求队列将通知某个工作在同步模式的工作线程来读取和处理该请求对象. 具体选择哪个工作线程取决于请求队列的设计.

Untitled

如果结合考虑两种事件处理模式和几种IO模型, 则半同步/半异步模式就产生多种变体, 如半同步/半反应堆(half-sync/half-reactive)模式(+Reactor模式).

Untitled

  • 该模式下, 只有主线程是异步线程, 负责监听所有socket上的事件. 所有工作线程都睡眠在请求队列上, 当有任务到来时, 他们通过竞争获得任务的接管权.

  • 图中, 主线程插入请求队列的是就绪的连接socket, 要求工作线程自己从socket上读写数据, 所以它采取的事件处理模式是Reactor模式.

    • 也可能采取Proactor事件处理模式, 将由主线程来完成数据的读写, 再将数据和任务类型封装后发给工作线程.
  • 缺点: 主线程和工作线程共享请求队列(需要加锁保护, 耗费资源); 每个工作线程只能同时处理单个客户请求.

Untitled

  • 更高效的半同步/半异步模式: 主线程只管理监听socket, 连接socket由工作线程来管理.
  • 这种模式并非严格意义的半同步/半异步模式, 每个线程(主线程和工作线程)都维持自己的事件循环, 都工作在异步模式, 各自独立地监听不同的事件.

领导者/追随者模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到O事件,首先要从线程池中推选出新的领导者线程,然后处理/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。

Untitled

该模式有以下组件, 他们的关系如图8-12所示:

  • 句柄集(HandlerSet):句柄(Handle)表示IO资源, 在Linux下就是文件描述符. 该集合管理众多句柄, 它使用wait_for_event方法来监听IO事件, 并将其中的就绪事件通知给领导者线程. 领导者则调用绑定在Handle上的事件处理器来处理事件.
  • 线程集(ThreadSet): 该集合包含和管理所有工作线程, 即领导者和追随者线程. 它负责线程之间的同步以及新领导者的推选. 集合中的线程必处于三种状态之一:
  1. Leader: 处于领导者身份, 等待句柄上的IO事件

  2. Processing: 正在处理事件. Leader检测到IO事件后, 可以选取新的Leader后转移到该状态, 也可以指定其他追随者来处理事件(Event Handoff). 当处于Processing状态的线程处理完事件之后, 如果当前没有领导者, 则它成为系的呢领导者, 否则转变为追随者.

  3. Follower: 处于追随者身份, 通过调用线程集的join方法等待成为新的领导者, 也可能被领导者指定来处理事件.

Untitled

  • 事件处理器(EventHandler): 通常包含至少一个回调函数handle_event, 用于处理事件对应的业务逻辑. 事件处理器在使用前需要被绑定到某个句柄上, 当事件发生时, 领导者就执行与之绑定的事件处理器中的回调函数.
  • 具体的事件处理器(ConcreteEventHandler): 事件处理器的派生类, 重新实现基类的handle_event方法以处理特定的任务.

Untitled

领导者线程自己监听IO事件并处理客户请求, 因而领导者/追随者模式不需要再线程之间传递任何额外的数据, 也不需要再线程之间同步对请求队列的访问. 但该模式的明显缺点时仅支持一个事件源集合, 因此无法做到图8-11那样让每个工作线程独立地管理多个客户连接.

8.6 有限状态机

网络编程中, 由于存在各种各样的情况, 逻辑单元内部往往需要实现较复杂的规则, 因此本小节以HTTP头部解析为例介绍了逻辑单元内部的一种高效编程方法: 有限状态机(finite state machine)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*主状态机的两种可能状态,分别表示:当前正在分析请求行,当前正在分析头部字段*/
enum CHECK_STATE {CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER};
/*从状态机的三种可能状态,即行的读取状态,分别表示:读取到一个完整的行、行出错和行数据尚且不
完整*/
enum LINE_STATUS {LINE_OK = 0, LINE_BAD, LINE_OPEN};
/*服务器处理HTTP请求的结果: NO_REQUEST表示请求不完整, 需要继续读取; GET_REQUEST表示获得了一个完整的客户请求; 其他均为异常情况*/
enum HTTP_CODE {NO_REQUEST, GET_REQUEST, BAD_REQUEST, FORBIDDEN_REQUEST,
INTERNAL_ERROR, CLOSED_CONNECTION};

/* 从状态机, 用于解析出一行内容 */
LINE_STATUS parse_line(char* buffer, int& checked_index, int& read_index);

/* 分析请求行 */
HTTP_CODE parse_requestline(char* temp, CHECK_STATE& checkstate);

/* 分析头部字段 */
HTTP_CODE parse_headers(char* temp);

/* 分析HTTP请求的入口函数 */
HTTP_CODE parse_content(char* buffer, int& checked_index, CHECK_STATE& checkstate,
int& read_index, int& start_line);

  1. IO处理单元接收到客户的数据后, 调用parse_content函数来分析新读入的数据, 最开始的主状态机状态为CHECK_STATE_REQUESTLINE(HTTP头部的第一行为请求头, 后面行为头部字段).

  2. parse_content调用parse_line函数来获取一个行.

Untitled

parse_line内部更新checked_index, 并返回新的从状态机状态(行的读取状态)

  1. 当从状态机由LINE_OPEN更新到LINE_OK时, parse_content根据主状态机状态分别调用parse_requestline和parse_headers函数解析行(若成功解析请求行, 则更新状态为CHECK_STATE_HEADER), 然后再次更新从状态机至LINE_OPEN.

若parse_headers检测到请求头结束标识符(), 则return GET_REQUEST, 此时parse_content函数也return GET_REQUEST, 结束HTTP请求头解析.

8.7 提高服务器性能的其他建议

如果服务器的硬件资源充裕, 那么提高服务器性能的一个直接方式就是以空间换取时间, 即”浪费”服务器的硬件资源, 以换取其运行效率, 这就是池(pool)的概念.

池是一组资源的集合, 再服务器启动之初就被完全创建并初始化, 这称为静态资源分配. 当程序后续需要相关资源时, 不再进行耗时地请求系统分配资源, 而是直接从池中申请; 等使用结束后也无需执行系统调用释放资源, 而是直接把资源再放回池. 池有很多种, 常见的有内存池, 进程池, 线程池和连接池.

由于池的资源时预先静态分配的, 因此对资源的提前预估或后期动态分配是主要问题.

数据复制

高性能服务器应该避免不必要的数据复制, 尤其是当数据复制发生在用户代码和内核之间时. 如果内核可以直接处理从socket或者文件读入的数据, 则应用程序就没有必要将这些数据从内核缓存区复制到应用程序缓存区. 另外, 用户代码内部的数据复制也是应该避免的.

上下文切换和锁

并发程序必须考虑上下文切换(context switch)的问题, 即进程切换或者线程切换导致的系统开销. 即使是IO密集型的服务器, 也不应该使用过多的工作线程, 否则上写问切换将占用大量CPU资源. 因此为每个客户连接都创建一个工作线程的服务器模型时不可取的.

并发程序需要考虑的另一个问题时共享资源的加锁保护. 锁通常被认为是导致服务器效率低下的一个因素, 因为由他引入的代码不仅不处理任何业务逻辑, 而且需要访问内核资源. 因此, 如果服务器有更好的解决方案, 应该避免使用锁. 如果必须使用锁, 可以考虑减小锁的粒度.

  • Title: 第八章 高性能服务器程序框架
  • Author: Huan Lee
  • Created at : 2023-08-20 08:08:08
  • Updated at : 2024-02-26 04:53:15
  • Link: https://www.mirthfullee.com/2023/08/20/notion-第八章 高性能服务器程序框架-ad4f9420/
  • License: This work is licensed under CC BY-NC-SA 4.0.